# 拓扑图实现导出大数据量时的图片
# 背景
由于浏览器对canvas有最大尺寸限制,导致在大数据量时(canvas很宽很宽)会导出图片失败,需要想别的办法去解决。
# 方案一:限制最大尺寸
最开始了解到canvas有最大尺寸后,想到的方案是给一个最大尺寸,将canvas的包围盒尺寸按比例缩小。
# 等比缩小
这里给个经验值20000。
// 做个最大尺寸的限制,避免超出后截不全
const oWidth = vWidth;
// 最大高度
const maxHeight = 20000;
// 最大宽度
const maxWidth = 20000;
if (vWidth > maxWidth && vHeight > maxHeight) {
// 更宽更高,
if (vHeight / vWidth > maxHeight / maxWidth) {
// 更加严重的高窄型,确定最大高,压缩宽度
vHeight = maxHeight;
vWidth = maxHeight * (vWidth / vHeight);
} else {
// 更加严重的矮宽型, 确定最大宽,压缩高度
vWidth = maxWidth;
vHeight = maxWidth * (vHeight / vWidth);
}
} else if (vWidth > maxWidth && vHeight <= maxHeight) {
// 更宽,但比较矮,以maxWidth作为基准
vWidth = maxWidth;
vHeight = maxWidth * (vHeight / vWidth);
} else if (vWidth <= maxWidth && vHeight > maxHeight) {
// 比较窄,但很高,取maxHight为基准
vHeight = maxHeight;
vWidth = maxHeight * (vWidth / vHeight);
} else {
// 符合宽高限制,不做压缩
}
# 改变矩阵运算的缩放值
matrix的0和4位是缩放比例,这里除以之前缩小的比例得到正确的值。
const rate = oWidth / vWidth;
matrix[0] /= rate;
matrix[4] /= rate;
# 导出
之后就是按照g6.js
的api去导出图片即可。
# 解决图片模糊问题
这里通过改变设备的像素比来解决。
导出前:
window.devicePixelRatio = 3;
导出后恢复原像素比。
# 缺点
这种方案在设备数量少的时候效果还是可以的,当数量大了后canvas实在是太大了,3000台设备能达到50000+px的尺寸,导致被过量压缩,清晰度下降甚至会看不清。
# 方案二:分批绘制后导出
既然是超出canvas限制尺寸了,那就缩小尺寸吧。
# 判断尺寸
const { width, height } = this.get('group').getCanvasBBox();
通过获取包围盒的尺寸,与设置的最大尺寸(20000,经验值)比较,大于则分批绘制数据然后导出图片,否则采用方案一直接导出。
# 切割数据
通过以下方法将画布切割成一个二维数组
const splitArr = Array.from(
new Array(Math.ceil(height / this.MAX_CANVAS_SIZE)),
() => Array.from(
new Array(Math.ceil(width / this.MAX_CANVAS_SIZE)),
() => ({ nodes: [], edges: [], display_options: this.parent._options.data.display_options })
)
);
然后找出在该区域的节点
const { nodes, edges } = this.save();
for (let rowIndex = 0; rowIndex < splitArr.length; rowIndex++) {
for (let columnIndex = 0; columnIndex < splitArr[0].length; columnIndex++) {
const areaMinX = minX + columnIndex * this.MAX_CANVAS_SIZE;
const areaMinY = minY + rowIndex * this.MAX_CANVAS_SIZE;
const areaMaxX = areaMinX + this.MAX_CANVAS_SIZE;
const areaMaxY = areaMinY + this.MAX_CANVAS_SIZE;
const nodesMap = new Map();
nodes.forEach(node => {
const { x, y, id } = node;
// 在该区域内
if (x >= areaMinX && x < areaMaxX &&
y >= areaMinY && y < areaMaxY &&
this.findById(id).isVisible()
) {
splitArr[rowIndex][columnIndex].nodes.push(node);
nodesMap.set(id, 1);
}
});
edges.forEach(edge => {
const { source, target } = edge;
if (nodesMap.has(source) || nodesMap.has(target)) {
splitArr[rowIndex][columnIndex].edges.push(edge);
}
});
}
}
# js队列
下面的方法需要用到队列,这里手动实现一个。
原理就是用数组记录一个promise队列,使用时等前一个结束后再执行下一个。
/**
* promise队列
*/
export default class Queue {
constructor() {
this.queue = [];
}
push(task) {
this.queue.push(task);
}
execute() {
const res = [];
return new Promise(resolve => {
this.loop(res, resolve);
});
}
loop(res, resolve) {
const nextTask = this.queue.shift();
if (nextTask) {
nextTask().then((result) => {
res.push(result);
this.loop(res, resolve);
});
} else {
resolve(res);
}
}
}
# 分批渲染
# 创建个新拓扑实例
const graphOptions = cloneDeep(this.parent._options);
const div = document.createElement('div');
div.style.width = `${graphOptions.width}px`;
div.style.height = `${graphOptions.height}px`;
div.style.position = 'absolute';
div.style.left = '-999999999px';
document.body.appendChild(div);
graphOptions.container = div;
graphOptions.data = null;
graphOptions.devMode.mock.enable = false;
graphOptions.plugins = graphOptions.pluginsOption;
const newGraph = new UEDGraph(graphOptions);
# 创建队列
const queue = new Queue();
// 将之前的二维数组循环推入
queue.push(async (resolve) => {
newGraph.graph.clear();
newGraph.graph.on(CUSTOM_EVENT.updateEnd, async () => {
await this.sleep(2000);
await newGraph.graph.downloadFullImage(options);
resolve();
}, true);
await newGraph.updateGraph(
splitArr[rowIndex][columnIndex],
{
needCalculatePosition: false,
showMaxNodeBranch: false,
autoFit: false,
showLoading: false,
needTransform: false
}
);
});
在渲染完数据后执行图片的导出。
# 执行
await queue.execute();
document.body.removeChild(div);
newGraph.destroy();
# 缺点
这种方案的问题还是比较多的。
- 当连线设备在范围外时,会将这部分连线丢失。
- 截出来的图片尺寸不一致
由于问题1无法解决,所以放弃了这种方案。
# 方案三:使用puppeteer截图
# 安装
npm i puppeteer-core
由于开发环境在内网,还需要手动下载chrome包
下载教程可参考http://www.qb5200.com/article/346953.html
# 使用
const puppeteer = require('puppeteer-core');
const path = require('path');
(async () => {
const browser = await puppeteer.launch({
// 这里注意路径指向可执行的浏览器。
// 各平台路径可以在 node_modules/puppeteer-core/lib/BrowserFetcher.js 中找到
// Mac 为 '下载文件解压路径/Chromium.app/Contents/MacOS/Chromium'
// Linux 为 '下载文件解压路径/chrome'
// Windows 为 '下载文件解压路径/chrome.exe'
executablePath: path.resolve('./chrome-win/chrome.exe')
});
const page = await browser.newPage();
await page.setViewport({
width: 1920,
height: 1080
});
await page.goto('http://127.0.0.1:8080/example/tree/', {
waitUntil: 'networkidle0',
timeout: 0
});
// await page.screenshot({
// path: 'marx-blog.png',
// fullPage: true,
// });
let canvas = await page.$('#ued-graph canvas');
//调用页面内Dom对象的screenshot 方法进行截图
canvas.screenshot({
path:'form.png'
});
await browser.close();
})();
可以直接对页面截图,或者指定dom。
尝试了都只能对当前显示内容进行截图,无法去滚动canvas中的内容。失败告终
# 方案四:做矩阵运算
G6.js
可以导出可视区域和整个图的图片,于是通过查看源码,发现导出可视区域的原理就是canvas只支持将显示了的元素导出。整个图是通过重新渲染一个canvas,然后将所有元素的包围盒的尺寸赋给它,即让这个虚拟的canvas直接显示所有的元素,然后再通过toDataURL
导出。
# 原理
知道原理后,就想到了下面这招:
给定最大尺寸,超出尺寸的不像方案一一样去等比缩小,而是对拓扑做偏移。
相当于在有限的尺寸上显示部分元素,导出后再做下一个区域的偏移,直到将所有区域完成导出。
# 实现
# 判断尺寸
同样,判断尺寸,超出做分批导出的操作
# 分批
const { width } = this.get('group').getCanvasBBox();
const length = Math.ceil(width / this.MAX_CANVAS_SIZE);
const maxWidth = width / length;
const queue = new Queue();
for (let i = 0; i < length; i++) {
queue.push(async () => {
return await this.downloadFullImage({
...options,
maxWidth,
splitIndex: i,
download: true
});
});
}
return await queue.execute();
循环需要分批的次数去截图。
这里对G6
的downloadFullImage
做了重写,使用promise包裹。
调用时根据maxWidth
字段创建的虚拟canvas的宽度。
const realWidth = maxWidth || vWidth;
const canvasOptions = {
container: vContainerDOM,
height: vHeight,
// 有给最大宽度就用最大的
width: realWidth
};
通过splitIndex
做矩阵运算的偏移
// 根据切割的索引往左做偏移
if (splitIndex !== undefined) {
matrix[6] = maxWidth * splitIndex * -1;
}
返回的格式改为状态和base64
格式。
[status, dataURL]
# 服务端实现图片拼接
成功导出图片后,就需要服务端去做图片的拼接。
因为前端拼接图片也是通过canvas去实现,这里导出的图片本来就超过canvas的尺寸限制了,所以得借助服务端的能力去实现。
至此,终于实现了大数据量下的canvas图片导出功能。